angr 使用总结

angr 是一个多架构二进制分析工具包,能够执行动态符号执行(类似于 Mayhem、KLEE 等)以及各种静态分析。
提示
我们可以简单的将 angr 理解为 IdaPython + 符号执行。也就是说:
- 我们可以将 angr 作为 IdaPython 的替代品。
- 可以批量对二进制程序进行静态分析,因为不需要将待分析的二进制文件逐个用 Ida 打开。
- 可以加快分析速度,因为我们不需要像 IdaPython 那样对程序进行完整的分析,比如说我们可以先通过搜索特征定位到关键位置,然后只分析关键位置处的代码。
- 可以结合 Unicorn 等模拟执行工具实现程序的动态分析,可以应对批量处理分析二进制程序时遇到不同程序之间代码存在差异的问题。
- 我们还可以使用 angr 的符号执行功能,来弥补静态分析和动态分析的缺陷。
- 静态分析可能难以处理复杂的控制流(如动态跳转),而动态分析需要依赖真实输入才能覆盖路径。angr 的符号执行功能通过符号化输入的方式探索路径,可以弥补这两者的不足。
- 由于结合 Unicorn 等模拟执行工具实现程序的动态分析是手动结合的,对于一些特殊的二进制文件可能会出问题,如果可以的话直接用 angr 的符号执行计算一些结果会稳定一些。
angr 安装
直接通过 pip 安装。
1 | pip3 install angr |
另外还有一个 angr-management
是基于 angr 实现的低配版 IDA。
1 | pip3 install angr-management |
安装完之后运行 angr-management
命令即可启动,用法和 IDA 基本一样。不过这里我们主要还是在编写逆向辅助工具的时候使用里面的一些 API。
1 | angr-management |
静态分析
二进制文件加载
加载项目(Project)
加载选项
angr 的第一步是将二进制文件加载到一个项目中。
项目(Project)是你在 angr 中的控制中心。通过它,你可以对加载的可执行文件执行分析和模拟。在 angr 中,几乎所有你要使用的对象都在某种形式上依赖于一个项目。
我们以 /bin/true
为例:
1 | import angr |
当你使用 angr.Project
加载文件时,可以将选项直接传递给 Project
构造函数,它们会被转发给 CLE。CLE 有如下常用选项:
auto_load_libs
:控制是否自动解析共享库依赖,默认值为True
。except_missing_libs
:与auto_load_libs
相反。如果设置为True
,当无法解析共享库依赖时会抛出异常。force_load_libs
:一个字符串列表,强制指定某些库为未解析的共享库依赖。skip_libs
:一个字符串列表,防止某些库名被解析为依赖。ld_path
:一个字符串或字符串列表,用作共享库的额外搜索路径,优先于默认路径。
你还可以使用 main_opts
和 lib_opts
来针对特定的二进制对象设置选项:
main_opts
:是一个选项名称到值的映射,适用于主二进制。lib_opts
:是一个以库名为键、选项字典为值的映射,适用于特定共享库。
常见选项包括:
backend
:指定使用的后端。CLE 当前支持以下静态加载后端:ELF、PE、CGC、Mach-O、ELF 核心转储(core dump)文件。通常情况下,CLE 会自动检测正确的后端,因此除非有非常特殊的需求,否则无需手动指定后端。
如果需要强制使用某个后端,可以在选项字典中包含一个
backend
键。某些后端无法自动检测架构,必须通过arch
参数指定。以下是支持的后端列表:
后端名称 描述 是否需要指定架构(arch)? elf 基于 PyELFTools 的 ELF 文件静态加载器 否 pe 基于 PEFile 的 PE 文件静态加载器 否 mach-o Mach-O 文件静态加载器,不支持动态链接或重定位 否 cgc Cyber Grand Challenge 二进制文件静态加载器 否 backedcgc 支持指定内存和寄存器的 CGC 二进制加载器 否 elfcore ELF 核心转储静态加载器 否 blob 将文件作为平坦映像加载到内存中 是 base_addr
:指定基址。entry_point
:指定入口点。arch
:指定架构。
1 | angr.Project( |
基本属性
加载项目后,可以查看一些基本属性,比如 CPU 架构、文件名和入口点的地址。
1 | import monkeyhex # 用于以十六进制格式显示数值结果 |
arch
是一个archinfo.Arch
对象的实例,表示程序的编译架构。在本例中,它是小端的 AMD64 架构。该对象包含关于 CPU 的大量信息,常用的属性有arch.bits
(位数)、arch.bytes
(字节数)、arch.name
和arch.memory_endness
。entry
是二进制文件的入口点地址。filename
是二进制文件的绝对路径。
加载器(The Loader)
将二进制文件加载为虚拟地址空间的表示形式是一个复杂的过程。angr 中有一个模块叫 CLE(CLE Loads Everything)来处理这个问题。CLE 可以通过项目的 .loader
属性访问。
1 | proj.loader |
已加载的对象
CLE 加载器将二进制文件及其所依赖的动态库(这里被称之为二进制对象)加载并映射到一个统一的内存空间中。每个二进制对象由能够处理其文件类型的加载器后端加载(如 cle.Backend
的子类)。例如,cle.ELF
用于加载 ELF 二进制文件。
此外,内存中还有一些对象并不对应任何加载的二进制文件,例如提供线程局部存储(TLS)支持的对象和用于未解析符号的 externs
对象。
CLE 加载器内部对这些对象简单做了一些分类,并存放在 loader
的几个属性中。常见的包括:
all_objects
:即 CLE 加载的所有对象。1
2
3
4
5
6
7
8# 所有加载的对象
proj.loader.all_objects
[<ELF Object fauxware, maps [0x400000:0x60105f]>,
<ELF Object libc-2.23.so, maps [0x1000000:0x13c999f]>,
<ELF Object ld-2.23.so, maps [0x2000000:0x2227167]>,
<ELFTLSObject Object cle##tls, maps [0x3000000:0x3015010]>,
<ExternObject Object cle##externs, maps [0x4000000:0x4008000]>,
<KernelObject Object cle##kernel, maps [0x5000000:0x5008000]>]main_object
:主对象,即angr.Project
指定加载的二进制文件。1
2# 加载多个二进制文件时,这是主对象 proj.loader.main_object
<ELF Object true, maps [0x400000:0x60721f]>shared_objects
:共享对象,即主对象所依赖的动态库。shared_objects
以字典的形式表示,内容为从共享对象名称到对象的映射。1
2
3
4proj.loader.shared_objects
{ 'fauxware': <ELF Object fauxware, maps [0x400000:0x60105f]>,
'libc.so.6': <ELF Object libc-2.23.so, maps [0x1000000:0x13c999f]>,
'ld-linux-x86-64.so.2': <ELF Object ld-2.23.so, maps [0x2000000:0x2227167]> }all_elf_objects
:所有从 ELF 文件加载的对象。如果是 Windows 程序,可以使用all_pe_objects
。1
2
3
4proj.loader.all_elf_objects
[<ELF Object fauxware, maps [0x400000:0x60105f]>,
<ELF Object libc-2.23.so, maps [0x1000000:0x13c999f]>,
<ELF Object ld-2.23.so, maps [0x2000000:0x2227167]>]extern_object
:用于为未解析的导入和 angr 内部地址。1
2proj.loader.extern_object
<ExternObject Object cle##externs, maps [0x4000000:0x4008000]>kernel_object
:用于模拟系统调用的对象。1
2proj.loader.kernel_object
<KernelObject Object cle##kernel, maps [0x5000000:0x5008000]>
除了从 loader
的某个属性中获取加载对象外,我们还可以通过 find_object_containing
方法获取指定地址所在的对象:
1 | 0x400000) proj.loader.find_object_containing( |
也可以通过 find_object
方法根据对象名称获取对象:
1 | 'fauxware') proj.loader.find_object( |
对象基本信息
CLE 加载的二进制对象的属性中包含了一些基本信息:
entry
:对象的入口点1
2proj.loader.main_object.entry
0x400580min_addr
,max_addr
:对象的最低地址和最高地址,即对象所在的地址空间范围。1
2proj.loader.main_object.min_addr, obj.max_addr
(0x400000, 0x60105f)linked_base
:对象的预链接基址预链接基址(Prelinked Base Address) 是在预链接(Prelinking) 过程中为共享对象(Shared Objects,如共享库
*.so
或可执行文件)分配的固定加载地址。1
2obj.linked_base
0x400000mapped_base
:对象实际被 CLE 映射到内存的基址1
2obj.mapped_base
0x400000execstack
:查询该二进制文件是否有可执行栈,即NX
保护是否未被开启。1
2proj.loader.main_object.execstack
Falsepic
:查询该二进制文件是否是地址无关,即PIE
保护是否开启。1
2proj.loader.main_object.pic
True
例如用 angr.Project(path, auto_load_libs=False)
载入目标,拿到 proj.loader.main_object
(CLE 的 ELF 对象),然后从 ELF 头 / Program Header / Dynamic 等处提取信息得到程序的保护情况。
PIE
1
"PIE": bool(getattr(obj, "pic", False))
- CLE 会根据 ELF 类型(
ET_DYN
且有解释器)等信息设置obj.pic
。 - 意义:PIE(位置无关可执行)导致装载基址随机化(配合 ASLR),影响泄露与劫持的策略;非 PIE 常出现绝对地址引用。
注意
极少见的“静态 PIE/特殊链接”可能让该字段不可靠,但一般足够实用。
- CLE 会根据 ELF 类型(
RELRO(Partial / Full)
1
2
3has_relro = _has_relro_segment(obj) # PT_GNU_RELRO 存在
bind_now = _bind_now_enabled(obj) # DT_BIND_NOW / DF_BIND_NOW / DF_1_NOW
info["RELRO"] = "NONE" if not has_relro else ("FULL" if bind_now else "PARTIAL")- Partial RELRO:存在
PT_GNU_RELRO
,但未启用BIND_NOW
;启动后仅部分区段只读,.got.plt
仍可写 → 仍可 GOT 覆写。 - Full RELRO:存在
PT_GNU_RELRO
且启用BIND_NOW
;所有符号启动时解析,GOT 页重保护为只读 → 阻断 GOT 覆写。
提示
CTF 中
RELRO=PARTIAL
往往意味着可尝试free@GOT → system
等覆盖链;RELRO=FULL
则考虑 vtable/hook/IO/heap 等路线。- Partial RELRO:存在
NX(不可执行栈)
1
2
3nx_attr = getattr(obj, "nx", None) # 部分 CLE 直接给出
execstack = getattr(obj, "execstack", None) # PT_GNU_STACK 可执行性
info["NX"] = (bool(nx_attr) if nx_attr is not None else False) or (execstack is not None and not bool(execstack))- 基于
PT_GNU_STACK
的X
标志;这里做了 双路径兜底:obj.nx
为真或not obj.execstack
即认为 NX 开启。 - 意义:
NX=True
需要 ROP/ret2libc/ret2dlresolve 等;NX=False
可直接在栈上执行 shellcode。
注意
个别工具链会让
obj.nx
过于保守;加上execstack
判断能提升兼容性。- 基于
Stack Canary
1
2names = _all_symbol_names(obj)
info["Canary"] = ("__stack_chk_fail" in names) or ("__stack_chk_guard" in names)- 通过是否出现
__stack_chk_fail
/__stack_chk_guard
推断启用-fstack-protector*
。 - 意义:有 Canary 时,栈溢出需泄露 canary或绕过写入(如 off-by-one 不覆盖 canary)。
注意
静态链接/强 LTO 或严重 strip 可能导致符号不可见而漏报。更“真”的做法是检查重定位是否引用
__stack_chk_fail
。- 通过是否出现
FORTIFY
1
info["FORTIFY"] = any(n.endswith("_chk") for n in names) # 如 __memcpy_chk / __sprintf_chk
-D_FORTIFY_SOURCE
会把部分不安全 API 重写为*_chk
版本,添加边界检查。- 意义:存在
_chk
族符号提示启用 FORTIFY。
注意
仅“有符号”不等于“实际调用”,严格应检查调用/重定位点;本法偏宽,可能小幅误报,但对 CTF 足够。
RWX(可写且可执行的装载段)
1
info["RWX"] = any(seg.is_executable and seg.is_writable for seg in getattr(obj, "segments", []) or [])
- 扫
PT_LOAD
段,出现 W+X 违反 W^X 原则。 - 意义:存在 RWX 时可写入 shellcode 并直接跳转,利用链更简单。
- 扫
RPATH / RUNPATH
1
2info["RPATH"] = bool(getattr(obj, "rpath", None)) # DT_RPATH
info["RUNPATH"] = bool(getattr(obj, "runpath", None)) # DT_RUNPATH- 从
.dynamic
中读取库搜索路径设置。 - 意义:不安全搜索路径可能带来动态库劫持(SUID 情况下动态链接器会有额外限制)。
- 从
Stripped
1
2has_symtab = _has_section(obj, ".symtab")
info["Stripped"] = (not has_symtab)- 缺少
.symtab
视为 stripped(快速近似)。 - 意义:影响逆向效率,但不直接改变利用面。
- 缺少
关键辅助函数(原理与作用)
_has_relro_segment(obj)
依据obj.relro
(CLE 对 RELRO 的抽象)判断是否存在PT_GNU_RELRO
,并据此参与 PARTIAL/FULL 的判定。_iter_dynamic_entries(obj)
→_bind_now_enabled(obj)
兼容不同 CLE 版本的.dynamic
表示,尽量抽取(tag, val)
;在其中识别:DT_BIND_NOW
,或DT_FLAGS
含DF_BIND_NOW (0x8)
,或DT_FLAGS_1
含DF_1_NOW (0x1)
。
以此判断是否 BIND_NOW(Full RELRO 的关键)。
1
2
3
4
5
6
7def _bind_now_enabled(obj):
DF_BIND_NOW, DF_1_NOW = 0x8, 0x1
for tag, val in _iter_dynamic_entries(obj):
if str(tag) == "DT_BIND_NOW": return True
if str(tag) == "DT_FLAGS" and int(val) & DF_BIND_NOW: return True
if str(tag) == "DT_FLAGS_1" and int(val) & DF_1_NOW: return True
return False_nx_enabled(obj)
双路径:obj.nx
或not obj.execstack
,提升不同工具链/cle 版本下的兼容性。_all_symbol_names(obj)
聚合symbols/imports/exports/symbols_by_name
的名字集合,提高 canary/fortify 判定的召回率。_has_rwx_load(obj)
遍历segments
,查同时is_executable && is_writable
的装载段。
1 | #!/usr/bin/env python3 |
段(Segment)和节(Section)
CLE 加载的二进制对象还会解析获取对应二进制文件的段和节信息。这些信息分别存放在二进制对象的 segments
和 sections
属性中。
1 | # 获取 ELF 的段(segments)和节(sections) |
我们可以通过二进制对象的 find_segment_containing
和 find_section_containing
获取指定地址所位于的段和节。
1 | obj.find_segment_containing(obj.entry) |
angr 没有直接通过节的名称搜索节的 api,因此需要手动遍历节来获取。
1 | text_section = [section for section in self.project.loader.main_object.sections if section.name == '.text'][0] |
内存数据
angr 的内存操作接口分为 静态内存接口(加载时内存布局)和 动态内存接口(符号执行时的内存状态)。其中 project.loader.memory
属于静态内存接口,是二进制文件加载到内存后的初始布局(如代码段、数据段、符号表等)。
搜索内存 :首先我们可以通过其中的
find
方法搜索我们想要的数据,返回结果是一个迭代器:1
def find(self, data: bytes, search_min: int = None, search_max: int = None) -> Iterator[int]:
参数:
data
:要搜索的字节序列(bytes
类型)。这是你希望在内存中查找的数据模式。search_min
:可选参数,指定搜索的最小地址。只有在该地址之后或等于该地址的内存区域才会被搜索。如果不提供,默认从内存的起始位置开始。search_max
:可选参数,指定搜索的最大地址。只有在该地址之前或等于该地址的内存区域才会被搜索。如果不提供,默认搜索到内存的末尾。
返回值:该方法返回一个迭代器,迭代器会逐一返回包含字节序列
data
的所有内存地址。
读取内存 :通过
load
方法可以读取指定内存地址的数据,返回值是一个字节序列。此方法会读取指定地址开始的最多n
个字节,直到达到指定字节数或者遇到未分配的内存区域:1
def load(self, addr: int, n: int) -> bytes:
参数:
addr
:指定读取的起始内存地址。n
:要读取的字节数。
返回值:返回一个字节对象(
bytes
类型),包含读取到的数据。如果在读取过程中遇到未分配的内存区域,方法会停止,并返回已读取的字节数据。
写入内存 :通过
store
方法可以将字节数据data
写入到指定的内存地址addr
。如果写入操作超过了当前内存区域的范围,方法会尝试更新现有的内存区域,并抛出KeyError
异常。1
def store(self, addr, data):
- 参数:
addr
:要写入的目标内存地址(int
类型)。data
:要写入内存的字节数据(bytes
类型)。
- 返回值:此方法没有返回值。如果成功写入数据,它会直接修改内存中的数据;如果出现问题,它会抛出
KeyError
异常。
- 参数:
符号信息
符号地址
从 CLE 中获取符号最简单的方法是使用 loader.find_symbol
,它接受一个名称或地址,并返回一个 Symbol
对象。
1 | 'strcmp') strcmp = proj.loader.find_symbol( |
符号最有用的属性包括其名称(name
)、所属对象(owner
)以及地址(address
)。但符号的“地址”可能是模糊的,Symbol
对象提供了三种方式报告其地址:
.rebased_addr
:符号在全局地址空间中的地址,这也是打印输出中显示的地址。.linked_addr
:符号相对于二进制文件预链接基址(prelinked base)的地址。例如,这是readelf
等工具中报告的地址。.relative_addr
:符号相对于其所属对象基址(object base)的地址。在文献(尤其是 Windows 文献)中,这被称为 RVA(Relative Virtual Address)。
1 | strcmp.name |
除了调试信息外,符号还支持动态链接(dynamic linking)的概念。例如,libc 提供了 strcmp
作为导出符号,而主二进制程序依赖它。如果我们让 CLE 直接从主对象中返回 strcmp
符号,它会告诉我们这是一个导入符号(import symbol)。导入符号没有有意义的地址,但它会提供一个引用,指向用于解析它的符号(通过 .resolvedby
属性)。
1 | strcmp.is_export |
符号的 PLT 地址
对于某些符号,你可以通过加载的对象获取它们在 PLT表(Procedure Linkage Table)中的地址:
1 | obj = proj.loader.main_object |
符号的 GOT 地址
导入和导出符号之间的链接方式是通过重定位(relocations)管理的。重定位记录了以下信息:
当你将 [import] 符号与某个导出符号匹配时,请将导出符号的地址写入 [location](即符号对应的 GOT 表地址),格式为 [format]。
我们可以获取重定位的相关信息:
- 通过
obj.relocs
获取某个对象的所有重定位列表(以Relocation
实例表示)。 - 通过
obj.imports
获取从符号名称到重定位的映射。注意,导出符号没有对应的列表。
例如我们可以通过 imports
获取 exit
函数的 GOT 表地址:
1 | 'exit'].rebased_addr proj.loader.main_object.imports[ |
二进制代码分析
基本块(Blocks)
在 angr 中,基本块(Basic Block) 是指一段连续的、没有跳跃(即没有分支指令)的指令序列,通常在程序执行过程中,这些指令是按顺序执行的。
具体来说,基本块有以下几个特点:
没有跳跃或分支 :基本块内的指令是顺序执行的,不包含跳转(如
jmp
、call
、ret
等指令)或条件分支指令(如if
、branch
等)。当程序执行到一个基本块时,它会按照顺序执行该块内的所有指令,直到遇到跳转指令或基本块结束。注意
与 IDA 的 CFG 的代码块不同的是,angr 的代码块把函数调用(
call
)也作为代码块结束的标志。入口和出口 :每个基本块有一个入口(起始地址)和出口(结束地址)。出口通常是一个跳转或返回的指令,也可能是下一个基本块的开始。
分析单元 :在程序分析过程中,基本块是分析的最小单元。angr 就是通过将程序拆分成多个基本块来进行符号执行(symbolic execution)和路径探索。
基本块的获取
在 angr 中,通过 project.factory.block()
方法可以提取某个地址的基本块。
在
angr
中有很多类,其中大多数需要实例化一个项目(project)。为了避免你到处传递项目实例,我们提供了project.factory
,它包含了几个方便的构造器,用于创建你经常需要使用的常见对象。
这些基本块的内容被封装在 Block
对象中,你可以通过该对象访问基本块的反汇编信息、指令数量、指令地址等数据。
注意
project.factory.block
提取的代码块并不是控制流程图中的代码块。控制流图会考虑到代码的所有跳转关系,包括跳转到基本块的中间位置,因此在控制流图中,基本块的划分会比单纯用 project.factory.block
提取的基本块更加复杂。
1 | # 从程序入口点提取一个代码块 block = proj.factory.block(proj.entry) |
基本块的使用
每个 Block
对象包含一个反汇编的指令列表。你可以通过 block.capstone
获取该基本块的反汇编指令。capstone
是一个流行的反汇编库,angr
使用它来生成反汇编指令。
1 | for insn in block.capstone.insns: |
在我们开发逆向辅助脚本的时候,基本块的其中一个作用就是可以提取某个地址处的 gadget
。
1 | def get_gadget(addr): |
我们还可以借助基本块来进行一些复杂的程序分析,不过这里先不做介绍。
控制流程图(CFG)
控制流图(Control Flow Graph,简称 CFG) 是一种图形化的表示方法,用于描述程序中各个基本块之间的控制流关系。它将程序中的 基本块 作为节点,表示控制流的 跳转指令(如 jmp
、call
、ret
等)作为边。
利用 angr 提取 CFG
在 angr
中,有两种类型的控制流图(CFG)可以生成:静态 CFG(CFGFast
)和动态 CFG(CFGEmulated
)。
提示
如果你不确定使用哪个 CFG,或者遇到 CFGEmulated
的问题,建议首先尝试使用 CFGFast
。
CFGFast
使用静态分析来生成控制流图。它显著更快,但理论上受限于一些控制流转换只能在执行时解析的事实。这是其他流行的逆向工程工具执行的同类控制流图分析,其结果与它们的输出可比。CFGEmulated
使用符号执行来捕捉控制流图。尽管它理论上更精确,但它显著更慢。由于模拟精度的问题(如系统调用、缺少硬件特性等),通常它也不那么完整。
可以通过以下代码构建控制流图:
1 | import angr |
提示
控制流图分析不会区分来自不同二进制对象的代码。这意味着默认情况下,它会尝试分析通过加载的共享库进行的控制流。这几乎从来不是预期的行为,因为这会使分析时间变得极长。要加载没有共享库的二进制文件,可以在 Project
构造函数中添加以下关键字参数:load_options={'auto_load_libs': False}
。
控制流图的核心是一个 NetworkX
有向图(di-graph)。这意味着所有常规的 NetworkX
API 都可用:
1 | print("This is the graph:", cfg.graph) |
CFGNode
类的实例代表了控制流图中的每个基本块。你可以通过 cfg.get_any_node()
获取给定地址的任何一个节点,或者通过 cfg.get_all_nodes()
获取所有上下文下的节点。
由于程序可能在多个上下文中执行,相同的基本块在不同的上下文下可能会有不同的表现。因此,同一个基本块可能会在图中有多个节点(表示不同的执行上下文)。
1 | # 获取给定位置(入口点)对应的任意一个节点 |
CFGNode
还具有 predecessors
和 successors
属性,分别表示当前节点的前驱和后继节点。
1 | # 获取入口节点的前驱 |
在 IDA 中,控制流图是以函数为单位的,也就是说,IDA 将每个函数作为一个单独的基本块进行分析,并将函数的入口和出口视为控制流的边界。而在 angr
中,控制流图是基于 基本块 的,call
指令通常被视为 跳转(或控制流转移)的一个标志,而不是函数的边界。控制流图会继续分析 call
指令后的指令,但不会自动将其视为函数的边界。
angrmanagement
的 to_supergraph
函数用于将 angr
的控制流图转换成一个函数级别的控制流图。to_supergraph
会把 angr
的单个函数的 CFG 提取出来,并将其转化为 IDA 样式的图。
1 | def get_cfg(): |
手动提取 CFG
如果一个二进制程序比较大,那么使用 angr 内置的 CFG 生成方法会非常的慢。对于这种情况通常我们都会自己实现一个近似的提取 CFG 的方法,针对一个特定函数开始提取 CFG。
下面这段代码是针对 x86 架构遍历一个特定函数的 CFG 的代码:
1 | def bfs_cfg(self, block_handler: Callable[[List[CsInsn]], Any]): |
下面这段代码是针对 arm 架构遍历一个特定函数的 CFG 的代码:
1 | def bfs_cfg(self, block_handler: Callable[[List[CsInsn]], Any]): |
下面这段代码是针对 aarch64 架构遍历一个特定函数的 CFG 的代码:
1 | def bfs_cfg(self, block_handler: Callable[[List[CsInsn]], Any]): |
上述示例代码只是近似遍历指定函数 CFG,然后针对其中的每一个代码块调用回调函数。由于没有对整个二进制文件进行完整分析,并且例如判断函数边界等都过于简略,因此并不适用于所有情况(例如有 switch 跳转表的函数会丢失大量分支代码,需要额外编写针对跳转表的处理逻辑)。
函数
angr 的函数分析
控制流图(CFG)的结果会生成一个名为 函数管理器(Function Manager) 的对象,可以通过 cfg.kb.functions
访问。该对象最常见的使用方式是像字典一样访问,它将地址映射到 Function
对象,Function
对象可以提供关于函数的各种属性。
1 | entry_func = cfg.kb.functions[p.entry] |
Function
对象具有多个重要属性:
entry_func.block_addrs
:一个集合,包含函数中所有基本块的起始地址。entry_func.blocks
:包含该函数所有基本块的集合,可以使用 Capstone 进行反汇编和探索。entry_func.string_references()
:返回一个列表,包含函数中所有被引用的常量字符串。列表中的每个项是一个元组(addr, string)
,其中:addr
是字符串所在的二进制数据段中的地址。string
是一个 Python 字符串,包含字符串的实际内容。
entry_func.returning
:一个布尔值,表示函数是否能返回。False
表示该函数的所有路径都不会返回。entry_func.callable
:一个angr
的Callable
对象,表示该函数。你可以像调用 Python 函数一样调用它,并传入 Python 参数,返回的结果可能是实际结果(可能是符号化的),就像你运行了该函数一样。entry_func.transition_graph
:一个NetworkX
的有向图(DiGraph),描述函数内部的控制流。它类似于 IDA 所显示的每个函数级别的控制流图。entry_func.name
:函数的名称。entry_func.has_unresolved_calls
和entry_func.has_unresolved_jumps
:这些属性与检测 CFG 的不精确性有关。有时,分析无法检测出间接调用或跳转的目标地址。如果发生这种情况,该函数的相关属性将被设置为True
。entry_func.get_call_sites()
:返回一个列表,包含所有以调用指令结尾的基本块地址。entry_func.get_call_target(callsite_addr)
:给定一个调用地址callsite_addr
,返回该调用指令的目标地址。entry_func.get_call_return(callsite_addr)
:给定一个调用地址callsite_addr
,返回该调用指令应该返回的地址。
手动提取函数
实际上前面的遍历 CFG 本质上就是在遍历函数的代码,这里主要介绍一下如何确定函数的起始地址。
为了避免代码速度过慢,我们需要通过搜索特征而不是 angr 的函数分析来确定函数起始地址,这种方法虽然不严谨,但是在多数情况下是准确的。
下面这段代码是针对 x86 架构寻找函数开头的代码,主要思路是搜索函数开头的特征 push rbp; mov rbp, rsp
。
1 | def get_func_by_addr(addr, project, size=0x1000): |
下面这段代码是针对 arm 架构寻找函数开头的代码,由于 arm 架构的机器码长度比较固定,因此可以精确的分析汇编代码。不过要注意的是 arm 架构的函数后面可能会有一些全局变量的地址指针,被识别为汇编可能会影响分析结果,这里简单的用经验规则过滤一下。
1 | def get_func_by_addr(addr, project): |
下面这段代码是针对 aarch64 架构寻找函数开头的代码,同样是搜索汇编实现的。
1 | def get_func_by_addr(addr, project): |
引用
前面定位函数起始地址的前提是需要有一个函数内部的地址,而我们通常是用一些特征数据(例如字符串)的引用来定位函数内部的地址的。
其中 arm32 架构由于全局变量的地址会被写到引用的函数后面,因此我们可以直接通过搜索全局变量的地址的方式来定位函数。而对于 x86 和 aarch64 架构则需要我们扫描汇编预处理出全局变量的引用关系。
x86 架构
在 x86 架构下,我们常见的字符串引用的汇编代码一般是如下两种形式:
lea
形式,适用于地址无关代码。对于这种形式被引用的字符串的地址在汇编指令的硬编码中体现不出来,因此不能直接通过搜索地址的方式定位到引用字符串的汇编代码,需要扫描分析汇编预处理引用表。1
lea reg, [rip + offset];
mov
形式,直接将目标地址设置到寄存器中。对于这种形式被引用的字符串的地址在汇编指令的硬编码中自带字符串地址,因此可以通过在代码段中搜索字符串地址来定位。1
mov reg, address;
最终的代码实现如下:
1 | import json |
AMD64 架构
- 未开启 PIE(ET_EXEC /
main_object.pic == False
)
不做全量反汇编建索引;按需检索:当你查某个地址(或字符串地址)时,直接在.text
里按字节搜索该地址的小端编码:mov reg, imm64
→ 搜索 8 字节小端imm64
- 绝对内存操作(moffs/无基址)常见 32 位位移 → 额外搜索 4 字节小端
addr & 0xffffffff
命中点再可选用一次小片段反汇编做验证(默认开启,代价很小)。
- 开启 PIE(ET_DYN /
main_object.pic == True
)
采用你建议的思路:先用disasm_lite
扫一遍,只有当满足- 是我们关注的指令(默认
mov/lea/cmp/add/sub
),且 - 操作数字符串里出现
[
(内存访问)或(mov
且不含[
,可能是imm
)
- 是我们关注的指令(默认
时,再对该条指令切片做一次 detail
反汇编,提取:
RIP
相对引用:target = insn.address + insn.size + disp
- 立即数像指针:
mov reg, imm
且imm
落在非.text
段 - (兼容)绝对内存:
mem.base==0 && mem.index==0
时的disp
(部分编译器场景会出现)
1 | import json |
AArch64 架构
aarch 架构可以参考前面计算内存操作数对应的地址的方法扫描汇编进行预处理。
1 | import json |
符号执行
符号执行原理
基本概念
符号执行(Symbolic Execution)是一种程序分析技术,它通过使用符号(而不是具体的值)来代替程序中的输入数据,在程序执行时跟踪符号变量的值。这使得我们能够推理出程序的行为,而不需要实际运行它。符号执行可以帮助发现程序的潜在问题,如漏洞、错误和安全问题,尤其在静态分析、漏洞挖掘和逆向工程等领域有广泛应用。
符号执行中的 符号状态 和 路径约束 是符号执行中两个非常重要的概念,它们帮助我们表达程序的执行过程和各种条件。
- 符号状态(Symbolic State):当前状态所有参数的集合,用 表示。集合中的每个元素用表示初始参数的变量表示。
- 路径约束(Path Constraint):到达当前路径需要表示初始参数满足的关系,通常用 表示。
例如下面的程序:
1 |
|
对应的程序框图如下:
我们用 , 分别表示初始输入的参数 x
,y
。如果程序执行到 Path-1
,则:
约束求解
即根据符号执行求得的执行到目标位置时的状态,反推出初始时假设的各个变量的值。
例如上面计算出执行到 Path-1
时的 和 PC 。如果执行到 Path-1
则应当满足 PC 为真,进一步推出 为一组合法解。
为了进行约束求解,angr 内置了 z3 约束求解器(封装为 claripy)。
动态符号执行
由于 angr 分析基于的是低级语言,会涉及内存、寄存器等结构,如果全部符号化会使得路径约束变得十分复杂且没有必要。
因此 angr 采取动态符号执行(Dynamic Symbloic Execution)或者叫做混合执行(Concolic Execution)的方式,即将关键变量符号化,其他变量都赋一个合理的初始值。
angr 在默认情况下,只有从标准输入流中读取的数据会被符号化,其他数据都是具有实际值的。
符号执行引擎(Claripy)
Claripy 是由 z3 封装的二进制分析框架 angr 的核心符号执行引擎,专注于 符号表达式操作 和 约束求解。它为二进制分析提供了一套高级抽象接口,简化了符号变量管理、约束构建和求解过程,使复杂的符号执行任务更易实现。
位向量创建
位向量(Bitvectors) 是符号执行中一个非常核心的概念,特别是在像 angr 这样的符号执行框架中,位向量用于表示程序中变量的值和各种计算结果。
位向量是一个由多个比特(bit)组成的向量。在符号执行的上下文中,位向量被用来表示程序中未确定的数值(如变量、内存中的数据等)。每个比特的位置可以表示不同的数值或者数据状态。
我们可以通过 Claripy 创建位向量常量:
1 | import claripy |
除了位向量常量,我们还可以创建位向量符号:
1 | # 创建一个名为 "x" 的 64 位位向量符号 |
z3 支持 IEEE754 浮点数理论,因此 angr 也可以使用它们。主要的区别是,浮点数不是通过宽度来表示的,而是通过 claripy.fp.FSORT_FLOAT/claripy.fp.FSORT_DOUBLE
来表示。你可以使用 FPV
和 FPS
来创建浮点符号和浮点值。
1 | 3.2, claripy.fp.FSORT_DOUBLE) # 创建浮点值 a = claripy.FPV( |
浮点数和整数类型的向量可以互相转换。
如果是使用 raw_to_bv
和 raw_to_fp
转换则表示的是数据不变,数据的解释方式改变(就像你将浮点数指针转换为整数指针或反之一样)。
1 | a.raw_to_bv() |
如果是类型转换则需要使用 val_to_fp
和 val_to_bv
:
1 | a |
位向量运算
同样长度的位向量可以进行运算,其中 Pyhton 的整数类型也可以参与运算,在运算过程中会被强制转换为适当的类型。
1 | one + one_hundred |
但是,你不能执行 one + weird_nine
,因为操作数位向量的长度不同,这是一个类型错误。然而,你可以扩展 weird_nine
使它具有适当的位数:
1 | 64 - 27) weird_nine.zero_extend( |
zero_extend
将在位向量的左侧填充给定数量的零位。你还可以使用 sign_extend
来用最高位的副本进行填充,保持位向量在二进制补码有符号整数语义下的值。
位向量符号同样也可以参与到位向量运算中。你可以对它们进行任意算术运算,但你不会得到一个数字,而是得到一个 AST(抽象语法树)。
1 | x + one |
每个 AST 都有 .op
和 .args
属性:
op
是一个字符串,表示正在执行的操作。args
是该操作接受的输入值。
除非 op
是 BVV
或 BVS
(或其他少数几种情况),否则 args
都是其他的 AST,最终树将终止于 BVV
或 BVS
。
1 | 1) / (y + 2) tree = (x + |
另外浮点数向量也支持数学运算:
1 | a + b |
符号约束(Symbolic Constraints)
对任何两个相同类型的 AST 执行比较操作将生成另一个 AST。这个新生成的 AST 不是位向量,而是一个符号布尔值。
注意
AST 默认情况下的比较是无符号的。最后一个例子中的 -5
会被强制转换为 <BV64 0xfffffffffffffffb>
,它显然不小于 100。如果你想要进行有符号的比较,可以使用 one_hundred.SGT(-5)
(即“有符号大于”)。
1 | 1 x == |
符号布尔值可以通过 claripy.is_true/claripy.is_false
或本身的 is_true
和 is_false
方法来判断真假。
注意
is_true
和 is_false
只是用来判断符号布尔值是否永真或永假。对于结果不确定的符号布尔值两个方法都会返回 False
。
1 | 1 yes = one == |
另外符号布尔值不应直接在 if
语句或 while
语句的条件中使用,因为答案可能没有具体的真值,并且即使有具体真值也会触发异常。
通常情况下,Claripy 支持所有常见的 Python 操作符(如 +
、-
、|
、==
等),并通过 Claripy 实例对象提供了额外的一些操作。这些操作是 Claripy 提供的用于处理符号表达式的基本操作,通过它们可以进行位运算、条件判断、扩展和提取等操作,在符号执行和分析中非常有用。
名称 | 描述 | 示例 |
---|---|---|
LShR | 逻辑右移位操作(适用于位表达式,如 BV、SI)。 | claripy.LShR(x, 10) |
SignExt | 对位表达式进行符号扩展。 | claripy.SignExt(32, x) 或 x.sign_extend(32) |
ZeroExt | 对位表达式进行零扩展。 | claripy.ZeroExt(32, x) 或 x.zero_extend(32) |
Extract | 从位表达式中提取指定的位(从右侧零索引开始,包含边界)。 | 提取 x 最右边的字节:claripy.Extract(7, 0, x) 或 x[7:0] |
Concat | 将多个位表达式拼接成一个新的位表达式。 | claripy.Concat(x, y, z) |
RotateLeft | 将位表达式左旋转。 | claripy.RotateLeft(x, 8) |
RotateRight | 将位表达式右旋转。 | claripy.RotateRight(x, 8) |
Reverse | 对位表达式进行字节序反转。 | claripy.Reverse(x) 或 x.reversed |
And | 逻辑与(适用于布尔表达式)。 | claripy.And(x == y, x > 0) |
Or | 逻辑或(适用于布尔表达式)。 | claripy.Or(x == y, y < 10) |
Not | 逻辑非(适用于布尔表达式)。 | claripy.Not(x == y) 等同于 x != y |
If | 条件选择(If-then-else)。 | 选择 x 和 y 中的最大值:claripy.If(x > y, x, y) |
ULE | 无符号小于或等于。 | 检查 x 是否小于或等于 y :claripy.ULE(x, y) |
ULT | 无符号小于。 | 检查 x 是否小于 y :claripy.ULT(x, y) |
UGE | 无符号大于或等于。 | 检查 x 是否大于或等于 y :claripy.UGE(x, y) |
UGT | 无符号大于。 | 检查 x 是否大于 y :claripy.UGT(x, y) |
SLE | 有符号小于或等于。 | 检查 x 是否小于或等于 y :claripy.SLE(x, y) |
SLT | 有符号小于。 | 检查 x 是否小于 y :claripy.SLT(x, y) |
SGE | 有符号大于或等于。 | 检查 x 是否大于或等于 y :claripy.SGE(x, y) |
SGT | 有符号大于。 | 检查 x 是否大于 y :claripy.SGT(x, y) |
约束求解(Constraint Solving)
你可以将任何符号布尔值视为对符号变量有效值的断言,通过将其作为约束添加到状态中。然后,你可以通过请求对符号表达式的求值,查询符号变量的有效值。
1 | solver = claripy.Solver() |
如果我们添加了相互冲突或矛盾的约束,导致没有任何值可以赋给变量以满足约束条件,状态将变得不可满足(unsat),查询时会引发异常。你可以通过 solver.satisfiable()
检查状态是否可满足。
1 | solver.satisfiable() |
另外如果我们想要获取解的最大或最小值则应当使用 max
和 min
方法:
1 | max(x) solver. |
状态(States)
Project
对象只代表程序的一个“初始化镜像”。当你在 angr 中执行程序时,你实际上是在操作一个代表程序状态的对象——SimState
。
状态创建
我们可以通过 factory
的 entry_state
创建状态。
1 | state = proj.factory.entry_state() |
当然 entry_state
只是项目工厂提供的多个状态构造函数之一,常见的状态构造函数有:
.blank_state()
构造一个“空白”状态,数据大部分没有初始化。当访问未初始化的数据时,会返回一个没有约束的符号值。适用于要完全控制初始条件的场景。- addr :状态应该开始的地址,而不是入口点。
.entry_state()
:构造一个准备从主二进制的入口点开始执行的状态。- argc :用作程序
argc
的自定义值,可以是整数或比特向量。如果未提供,则默认为 args 的长度。 - args :一个值的列表,用作程序的
argv
。可以是混合字符串和比特向量。 - env :一个字典,用作程序的环境。键和值都可以是混合的字符串和比特向量。
- stdin :程序的输入流。可以是字符串或比特向量,不过最好长度给的要足够。
- argc :用作程序
.full_init_state()
:构造一个准备执行所有需要在主二进制入口点之前运行的初始化器的状态,例如共享库构造函数或预初始化器。当这些完成后,它将跳转到入口点。它可以接受entry_state
可以提供的任何参数,除了addr
。.call_state()
:构造一个准备执行给定函数的状态。- addr :状态应该开始的地址,而不是入口点。
- args :任何额外的位置参数将作为函数调用的参数。
SimState
包含了程序的内存、寄存器、文件系统数据等内容……任何在执行过程中可能被修改的“实时数据”都存储在这个状态中。
1 | # 获取当前的指令指针 state.regs.rip |
可以看到无论是内存还是寄存器,angr 的 SimState
都是用位向量的形式来维护。这种策略方便符号执行完之后进行约束求解。另外就是除了输入和用户指定的数据外,其余数据都是给定一个合理的初始值而不都是符号化,这样可以极大的简化最终生成的表达式的复杂程度。
内存设置
在 angr 中,state.mem
和 state.memory
是用于操作内存的两个核心接口,分别提供 类型化内存访问 和 原始字节级操作 的功能。
state.memory
提供 原始字节级操作,这意味着你可以直接访问内存的字节,而不需要考虑类型的转换。state.memory
用于处理低级操作,适合那些需要进行细粒度控制的场景,或者需要直接修改内存数据的情况。
load(addr, size)
:从地址addr
读取size
字节,返回位向量(claripy.BV
)。store(addr, data)
:将数据data
(位向量或字节)写入地址addr
。
1 | state = proj.factory.blank_state() |
注意
state.memory
的主要用途是加载和存储数据块,没有附加语义,因此数据默认按照“大端序”读写。如果你想对加载或存储的数据进行字节交换,你可以传递一个关键字参数 endness
。
endness
应该是 archinfo
包中的 Endness
枚举的成员,该包用于保存关于 angr
中 CPU 架构的声明性数据。此外,正在分析的程序的字节序可以通过 arch.memory_endness
获取,比如 state.arch.memory_endness
。
1 | import archinfo |
state.mem
提供 类型化内存访问,允许你对内存进行更高层次的操作,通常与寄存器、结构体、数组等数据结构的交互更为便捷。
注意
state.mem
赋值的时候可以使用bytes
、数字、位向量,但是位向量要确保类型长度一致。state.mem
按照二进制默认的大小端序读写。
1 | state = proj.factory.blank_state() |
寄存器设置
和内存接口一样,angr 的寄存器接口也有 state.regs
和 state.registers
两种。和 state.memory
一样,state.registers
也提供了没有具体类型的底层数据访问接口,因为 angr 的寄存器本质上也是通过某个地址空间的内存来模拟的。
不过在实际使用中我们通常还是使用 state.regs
接口来读写寄存器。
1 | import angr, claripy |
文件设置
SimFile
是 angr
中用于模拟文件操作的类,它实现了对文件的模拟,包括文件读取、写入、以及其他文件操作。它设计的目标是模拟磁盘文件的行为,并且允许符号执行引擎(symbolic execution engine)对文件的内容和文件系统操作进行符号化处理。
SimFile
构造函数中常用的参数有:
name
:文件的名称,用于标识文件。这个名称通常是文件路径的一部分。content
:可选的初始内容,可以是字符串或者位向量(bitvector)。如果没有提供内容,文件内容将默认为零。size
:可选的文件大小。如果没有提供大小,文件大小默认为零。如果提供了content
,则文件大小将根据内容的大小确定。
例如下面的示例代码,我们将 password.txt
这个文件符号化,这样如果程序的执行受到了该文件内容的影响,那么我们就可以在目标状态下求解文件的内容。
1 | state = proj.factory.entry_state() |
仿真管理器(Simulation Managers)
在 angr
中,仿真管理器(Simulation Managers)是用于管理模拟状态(SimState
)的核心组件之一。仿真管理器负责维护符号执行过程中所有可能的路径,并为每个路径创建并管理相应的状态。
仿真管理器创建
仿真管理器通过 factory
的 simulation_manager
构造函数生成,该函数接收一个状态或状态列表。一个仿真管理器可以包含多个状态堆栈。默认的状态堆栈是 active
,它使用我们传入的状态进行初始化。
1 | simgr = proj.factory.simulation_manager(state) |
在 angr 当中,不同的状态被组织到 simulation manager 的不同的 stash 当中,我们可以按照自己的需求进行步进、过滤、合并、移动等。在 angr 当中一共有以下几种 stash:
simgr.active
:活跃的状态列表。在未指定替代的情况下会被模拟器默认执行。simgr.deadended
:死亡的状态列表。当一个状态无法再被继续执行时(例如没有有效指令、无效的指令指针、不满足其所有的后继(successors))便会被归入该列表。simgr.pruned
:被剪枝的状态列表。在指定了LAZY_SOLVES
时,状态仅在必要时检查可满足性,当一个状态在指定了LAZY_SOLVES
时被发现是不可满足的(unsat),状态层(state hierarchy)将会被遍历以确认在其历史中最初变为不满足的时间,该点及其所有后代都会被 剪枝 (pruned)并放入该列表。simgr.unconstrained
:不受约束的状态列表。当创建SimulationManager
时指定了save_unconstrained=True
,则被认为不受约束的(unconstrained,即指令指针被用户数据或其他来源的符号化数据控制)状态会被归入该列表。simgr.unsat
:不可满足的状态列表。当创建SimulationManager
时指定了save_unsat=True
,则被认为无法被满足的(unsatisfiable,即存在约束冲突的状态,例如在同一时刻要求输入既是"AAAA"
又是"BBBB"
)状态会被归入该列表。
还有一种不是 stash 的状态列表——errored
,若在执行中产生了错误,则状态与其产生的错误会被包裹在一个 ErrorRecord
实例中(可通过 record.state
与 record.error
访问),该 record 会被插入到 errored
中,我们可以通过 record.debug()
启动一个调试窗口。
我们可以使用 stash.move()
来在 stash 之间转移放置状态,用法如下:
1 | 'unconstrained', to_stash = 'active') simgr.move(from_stash = |
在转移当中我们还可以通过指定 filter_func
参数来进行过滤:
1 | def filter_func(state): |
stash 本质上就是个 list,因此在初始化时我们可以通过字典的方式指定每个 stash 的初始内容:
1 | simgr = proj.factory.simgr(init_state, |
路径探索
仿真管理器以基本块为单位对程序进行符号执行,对应的方法为 simgr.step()
。每当仿真管理器调用一次 step
方法时:
- 内部维护的
active
列表中的所有活跃状态都会执行一个基本块。 - 每个状态在执行完一个基本块后根据基本块后根据执行的结果决定状态是否分裂或从
active
中移除。
我们可以循环调用 simgr.step()
然后遍历 active
列表判断是否有执行到我们预想的目标地址的状态。然后再对执行到目标地址的状态求解所需的输入。
1 | while len(simgr.active): |
上述过程实际上在仿真管理器中被封装成一个路径探索函数 simgr.explore()
。
explore
函数主要有两个参数:
find
:一个地址或条件,表示我们希望探索到的目标状态。当仿真管理器的任何路径到达该地址时,仿真过程会停止或返回该路径。avoid
:一个地址或条件,表示我们希望避免的状态。即仿真管理器会尽量避免路径到达此地址或条件,通常用于避开错误路径或崩溃点。
提示
find
和 avoid
可以接受多种类型的参数:
如果参数类型是数字表示的是地址,即仿真管理器应当或不应当执行到的地址。
如果参数是一个回调函数(或者
lambda
表达式),则会根据函数的返回结果对当前探索的路径进行剪枝。1
2
3
4simgr.explore(
find=lambda state: b'Good Job.' in state.posix.dumps(1),
avoid=lambda state: b'Try again.' in state.posix.dumps(1)
)
当 simgr.explore
执行完之后所有能执行到目标地址的状态都会放到 simgr.found
列表中。
另外仿真管理器还提供了多种技术来防止路径探索过程中出现路径爆炸的问题。例如出自2014年的一篇论文 Enhancing Symbolic Execution with Veritesting 的路径归并算法:
1 | # 在创建仿真管理器的时候指定开启 veritesting |
路径归并算法主要是结合了先前两种符号执行算法 DSE
(动态符号执行)和 SSE
(静态符号执行)的优缺点:
- 动态符号执行(DSE) :DSE 在执行过程中针对每一条路径进行符号执行,能够精确模拟每个路径的执行。然而,当程序包含大量条件分支时,路径数量会呈指数级增长,导致路径爆炸的问题,这对计算资源造成很大的压力。
- 静态符号执行(SSE) :SSE 在静态分析阶段通过控制流图(CFG)来处理路径,通常能减少路径的数量,避免路径爆炸问题。然而,它在处理包含复杂系统调用、间接跳转或其他难以静态推理的语句时效果较差(很难用符号约束表示整个程序的逻辑)。
Veritesting 算法结合了动态符号执行和静态符号执行的优势。当程序中遇到不适合静态分析的部分(如系统调用、间接跳转等),可以切换到静态符号执行;而对于可以精确分析的部分,则使用动态符号执行,从而提高了符号执行的效率和精度。同时 Veritesting 使用路径合并技术,将多个路径合并成一个路径,避免了 DSE 中路径数量爆炸的问题。通过合并路径,Veritesting 在保持精确度的同时,显著减少了需要处理的路径数量。当然具体的细节还得阅读论文。
另外根据官方的说法,Versitesting 通常与其他 exploration techniques 不兼容。
Note that it frequenly doesn’t play nice with other techniques due to the invasive way it implements static symbolic execution.
函数 hook
仿真管理器在路径探索的过程中,可能因为某个函数导致路径爆炸。例如:
程序自身实现的函数 :例如字符串比较函数,在比较到不同字符时跳出循环。如果该函数被符号执行,那么每循环一次所有状态都会因为跳出和不跳出循环两种情况而“分裂”一次。
静态链接的程序中调用的比较复杂的库函数 :例如
malloc
。这些函数在静态链接的程序中无法自动 hook,因为angr
默认只会自动 hook 动态链接的库函数。在 angr 中,动态链接的程序调用的库函数会被自动 hook 为 angr 自身实现的库函数,这样可以有效避免路径爆炸问题。但是对于静态链接的程序,即使有调试符号 angr 也不会去自动 hook 这些函数,从而可能导致路径爆炸的问题。
为了解决这一问题,我们需要对造成路径爆炸的函数进行 hook。angr
提供了两种主要的方式来 hook 函数:proj.hook
和 SimProcedure
。
project.hook
方法类似于二进制层面的 hook,即将指定位置的指定长度的二进制指令替换为调用我们自己实现的 Python 函数。具体来说,可以通过以下方式来 hook 掉对应的 call
指令:
1 | project.hook(addr = call_insn_addr, hook = my_function, length = n) |
call_insn_addr
:被 hook 的call
指令的地址。my_function
:我们自定义的 Python 函数。length
:call
指令的长度。
其中我们的自定义函数应该接受 state
作为参数,我们可以通过操作 state
模拟该函数对程序执行状态造成的影响。
1 | def my_hook_func(state): |
另外 angr 还支持注解的方式进行 hook,下面这段代码与前面的代码等价:
1 |
|
SimProcedure
主要用于替换文件中的原有函数,例如 angr
默认会使用一些内置的 SimProcedure
来替换掉一些常见的库函数。在二进制程序中,像 malloc
这样的复杂库函数通常会被自动 hook,以避免路径爆炸和进行符号化分析。
如果我们已经有该二进制文件的符号表,我们可以直接使用 project.hook_symbol(symbol_str, sim_procedure_instance)
来自动 hook 掉文件中所有的对应符号,run()
方法的参数为被替换函数所接收的参数。
1 | class MyCheckEquals(angr.SimProcedure): |
在 SimProcedure
的 run()
方法中,我们可以使用一些有用的成员函数来控制执行过程,例如:
ret(expr)
:返回一个表达式值。jump(addr)
:跳转到指定的地址。exit(code)
:终止程序执行,通常用于模拟程序退出。call(addr, args, continue_at)
:调用文件中的一个函数,args
是传递给函数的参数,continue_at
是继续执行的位置。inline_call(procedure, *args)
:内联地调用另一个SimProcedure
。
这些成员函数使得我们可以更灵活地控制程序的模拟执行,尤其在处理复杂的系统调用、库函数和跳转时非常有用。
符号求解
在完成路径探索之后,如果目标位置可达,则我们可以从仿真管理器的 found
列表中找到执行到目标位置的所有路径对应的状态:
1 | # 要求解的内容 |
state
中实际上内置了符号执行引擎 claripy
,前面的路径探索本质上就是为每个 state
内置的符号执行引擎中添加对应的条件。当执行到目标位置时,state
中的符号执行引擎已经添加了能够执行目标位置所需的所有条件。因此我们可以利用符号执行引擎的约束求解功能(state.solver
)求解出前面设置的需要求解的内容。
1 | found.solver.eval(bvs_to_solve) |
如果是标准输入之类的则不需要我们显式的调用约束求解,直接获取即可:
1 | simgr.found[0].posix.dumps(0) |
符号执行引擎不仅可以通过路径探索添加约束条件,还可以手动添加条件。因此在有些场景下我们不需要完整的执行整个过程,而是只执行前面一部分内容,而后的部分可以手动添加相应的规则。这种策略可以一定程度上避免一些路径爆炸的情况。
1 | found = simgr.found[0] |
- Title: angr 使用总结
- Author: sky123
- Created at : 2025-08-19 00:02:28
- Updated at : 2025-08-25 01:22:55
- Link: https://skyi23.github.io/2025/08/19/angr 使用总结/
- License: This work is licensed under CC BY-NC-SA 4.0.